package myatm;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.invocation.InvocationOnMock;
import org.mockito.runners.MockitoJUnitRunner;
import org.mockito.stubbing.Answer;

import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

/**
 * @author Tkalenko Roman, FI-12
 *
 * Implementation notes:
 * I didn't implement actual exception classes like "NoCardInATM" etc,
 * because in this case it's good enough to use UnsupportedOperationException
 * with specialized message for each kind of error.
 *
 * I also tried to minimize usage of Mockito's `verify()` and `inOrder`
 * (didn't use latter at all, actually).
 * That's because these methods are considered violating blackbox-testing principle
 * and are exposing internal details of implementation. This, in turn, breaks
 * incapsulation principle. I'd like to notice that I achieved the same result
 * by just a little bit more typing and making some strict assumptions in tests.
 */

@RunWith(MockitoJUnitRunner.class)
public class ATMSuite {
	@Mock public Card    cardWithPin1234;
	@Mock public Account account;

	public ATM atm;
	double moneyInATM     = 15;
	double moneyInAccount = 11;

	String blockedCardExnMessage       = "The card is locked or the PIN was invalid";
	String notEnoughMnyInAccExnMessage = "Not enough money in account";
	String notEnoughMnyInATMExnMessage = "Not enough money in ATM";
	String dontFunkWithMyATMExnMessage = "Can't get negative amount";
	String negativeAmountATMExnMessage = "Can't create an ATM with negative amount of money";
	@Rule
	public ExpectedException exn = ExpectedException.none();

	@Before
	public void init() {
		atm = ATM.getInstance(moneyInATM);
		when(cardWithPin1234.isBlocked()).thenReturn(false);
		when(cardWithPin1234.checkPin(9001)).thenReturn(false);
		when(cardWithPin1234.checkPin(1234)).thenReturn(true);
		when(cardWithPin1234.getAccount()).thenReturn(account);
		when(account.getBalance()).thenReturn(moneyInAccount);
		when(account.withdraw(anyDouble())).thenAnswer(new Answer<Double>() {
			@Override
			public Double answer(InvocationOnMock invocation) throws Throwable {
				double amt = (Double) invocation.getArguments()[0];
				moneyInAccount -= amt;
				// reloading mock's property because argument
				// values aren't being tracked in runtime
				when(account.getBalance()).thenReturn(moneyInAccount);
				moneyInATM -= amt;
				return amt;
			}
		});
		// IMPORTANT:
		// Card validation is a frequently duplicated piece of code all over the suite,
		// but since it's in some methods we intentionally pass wrong credentials
		// to the validation method, it should be left as is.
	}

	@Test
	public void validateCard_cardWithIncorrectPIN_returnsFalse() {
		assertFalse(atm.validateCard(cardWithPin1234, 9001));
	}

	@Test
	public void validateCard_cardWithCorrectPIN_returnsTrue() {
		assertTrue(atm.validateCard(cardWithPin1234, 1234));
	}

	@Test
	public void getCash_onLockedCard_throwsException() {
		when(cardWithPin1234.isBlocked()).thenReturn(true);
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(blockedCardExnMessage);
		atm.getCash(1d);
	}

	@Test
	public void getCash_onNotEnoughMoneyInATM_throwsException() {
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(notEnoughMnyInATMExnMessage);
		atm.validateCard(cardWithPin1234, 1234);
		atm.getCash(16);
	}

	@Test
	public void ATMgetInstance_onNegativeAmount_throwsException() {
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(negativeAmountATMExnMessage);
		atm = ATM.getInstance(-5);
	}

	@Test
	public void getCash_onNotEnoughMoneyInAccount_throwsException() {
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(notEnoughMnyInAccExnMessage);
		atm.validateCard(cardWithPin1234, 1234);
		atm.getCash(12);
	}

	@Test
	public void getCash_ifHasEnoughMoney_returnsWithdrawalDifference() {
		atm.validateCard(cardWithPin1234, 1234);
		assertEquals(8, atm.getCash(3), 1e-3);
	}

	@Test
	public void getCash_consequentWithdrawalsWithNotEnoughMoney_throwsException() {
		atm.validateCard(cardWithPin1234, 1234);
		// balance = 11
		assertEquals(2, atm.getCash(9), 1e-3);
		// balance = 2
		assertEquals(0, atm.getCash(2), 1e-3);
		// balance = 0; trying to get something:
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(notEnoughMnyInAccExnMessage);
		atm.getCash(1);

	}

	@Test
	public void getCash_afterSuccessfulOperationAmountOfMoneyInATM_decreases() {
		atm.validateCard(cardWithPin1234, 1234);
		atm.getCash(9);
		assertEquals(6, atm.getMoneyInATM(), 1e-3);
	}

	/**
	 * This test is fat, but I've commented it's workflow
	 * and the test's size fits with its logic.
	 */
	@Test
	public void anyOperation_useLockedCardAfterAnotherUnlockedWasUsed_throwException() {
		Card goodCard = mock(Card.class);
		Account accountForGoodCard = mock(Account.class);
		when(goodCard.checkPin(9001)).thenReturn(true);
		when(goodCard.isBlocked()).thenReturn(false);
		when(accountForGoodCard.getBalance()).thenReturn(9999d);
		when(goodCard.getAccount()).thenReturn(accountForGoodCard);
		// intentionally incorrect PIN:
		atm.validateCard(cardWithPin1234, 9001);
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(blockedCardExnMessage);
		assertEquals(9999d, atm.checkBalance(), 1e-3);
		// now, authorize good card:
		atm.validateCard(goodCard, 9001);
		atm.getCash(5);
		// now, authorize 1234-PIN-card
		// and verify that currentCard has now value
		// of the new card's account amount
		atm.validateCard(cardWithPin1234, 1234);
		assertEquals(11d, atm.checkBalance(), 1e-3);
	}

	@Test
	public void getCash_withdrawalHappensOnlyOnceDuringTheGetCashProcedure() {
		atm.validateCard(cardWithPin1234, 1234);
		atm.getCash(2);
		verify(account).withdraw(anyDouble());
	}

	@Test
	public void getCash_getNegativeCashAmount_throwException() {
		atm.validateCard(cardWithPin1234, 1234);
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(dontFunkWithMyATMExnMessage);
		atm.getCash(-2);
	}

	@Test
	public void validateCard_emptyCard_throwNullPointerException() {
		exn.expect(NullPointerException.class);
		atm.validateCard(null, 9009);
	}

	@Test
	public void getMoneyInATM_authorized_returnsExactAmount() {
		atm.validateCard(cardWithPin1234, 1234);
		assertEquals(15, atm.getMoneyInATM(), 1e-3);
		atm.getCash(1);
		assertEquals(14, atm.getMoneyInATM(), 1e-3);
	}

	@Test
	public void checkBalance_cardAuthorized_returnActualBalance() {
		Card goodCard = mock(Card.class);
		Account accountForGoodCard = mock(Account.class);
		when(goodCard.checkPin(9001)).thenReturn(true);
		when(goodCard.isBlocked()).thenReturn(false);
		when(accountForGoodCard.getBalance()).thenReturn(9999d);
		when(goodCard.getAccount()).thenReturn(accountForGoodCard);

		// checking one card
		atm.validateCard(cardWithPin1234, 1234);
		assertEquals(11, atm.checkBalance(), 1e-3);
		// checking another card
		atm.validateCard(goodCard, 9001);
		assertEquals(9999, atm.checkBalance(), 1e-3);
		// and first one again
		atm.validateCard(cardWithPin1234, 1234);
		assertEquals(11, atm.checkBalance(), 1e-3);
	}

	@Test
	public void getCash_withUnauthorizedCard_throwsEception() {
		atm.validateCard(cardWithPin1234, 4321);
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(blockedCardExnMessage);
		atm.getCash(9999);
		verify(account, never()).withdraw(anyDouble());
	}

	@Test
	public void getCash_notEnoughMoney_exceptionsAreThrownBeforeDecreasingAmount() {
		// get some money
		atm.validateCard(cardWithPin1234, 1234);
		atm.getCash(3);
		assertEquals(atm.getMoneyInATM(), 12, 1e-3);


		// we'd like to have more money than there is in ATM
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(notEnoughMnyInATMExnMessage);
		atm.getCash(100);
		assertEquals(12, atm.getMoneyInATM(), 1e-3); // did not decrease
		assertEquals(8, atm.checkBalance(), 1e-3); // did not decrease as well

		// we'd like to have more money than there is in account
		exn.expect(UnsupportedOperationException.class);
		exn.expectMessage(notEnoughMnyInAccExnMessage);
		atm.getCash(100);
		assertEquals(12, atm.getMoneyInATM(), 1e-3); // did not decrease
		assertEquals(8, atm.checkBalance(), 1e-3); // did not decrease as well
	}
}
